Skip to content

Range#max: beginless Integer ranges, as 4.0 answers them - #892

Merged
tannevaled merged 1 commit into
mainfrom
range-max-beginless
Nov 5, 2026
Merged

tannevaled merged 1 commit into
mainfrom
range-max-beginless

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Five of the 27 examples that went red on the move to Ruby 4.0 (#799) share one
cause, and this closes it. core/range/max_spec.rb goes 30 → 35, fail=0
error=0
.

The reference

range.c's range_max at v4.0.9 changed in two places that both say a nil
begin is not a disqualification
:

int c = NIL_P(b) ? -1 : OPTIMIZED_CMP(b, e);   /* beginless sorts BELOW the end */
if (!NIL_P(b) && !RB_INTEGER_TYPE_P(b)) {      /* the Integer-begin demand is   */
    rb_raise(... "cannot exclude end value with non Integer begin value");
}                                              /* now guarded by !NIL_P(b)      */

and the n-argument branch runs through reverse_each, which walks
(NIL_P(beg) || RB_INTEGER_TYPE_P(beg)) && RB_INTEGER_TYPE_P(end) downward
from the end with nothing but n to stop it.

What moved

before after (= MRI 4.0.7)
(...1).max TypeError 0
(..1).max(2) RangeError [1, 0]
(...1).max(2) RangeError [0, -1]
(0...2**64).max(2) TypeError can't iterate from 0 [18446744073709551615, …614]
(..1.0).max(2) RangeError TypeError can't iterate from NilClass
(..1).max(0) RangeError []
(...-2**63).max TypeError -9223372036854775809 (Bignum)

The predecessor of an exclusive end is taken in big.Int now, so the int64
floor stops being a special case — and (0...2**64).max(2) no longer tries to
materialise 2⁶⁴ elements.

⛔ The guard order is observable

range_max asks for a block before it looks at n. (..1).max(2) { |a,b| a<=>b }
raises in MRI; answering the n first returns [1, 0] and looks entirely
reasonable. The check moved to the top of the method for that reason, and a
test names it.

⛔ Two tests were MOVED, not relaxed

max_n_beginless and max_excl_beginless_int pinned the pre-4.0 refusals —
ruby/spec marks both ruby_version_is ""..."4.0". Each row now names the
refusal that replaced it, so the file still asserts a refusal for each shape
instead of losing one.

Measured, not read

Every expectation came from MRI 4.0.7 on this host through rubydiff, before
and after. Two of the three probe programs now AGREE byte-for-byte; the
third keeps one divergence that is pre-existing and has nothing to do with
beginless ranges — (1..10).max(2) { |a,b| -(a<=>b) } is [1, 2] in MRI and
[10, 9] here. Filed as #891 rather than smuggled in; the single upstream
spec for that shape uses the identity comparison, so it cannot tell the two
apart.

specdelta, provenanced, over all 34 core/range files:

control   rbgo-rmaxC — built from fdf0939840e8
treatment rbgo-rmaxT — built from e2f411f71d7a
MOVE none declared: both engines read the same corpus, so every delta is the ENGINE

core/range/max_spec.rb      30   35   +5
core/range/min_spec.rb      30   30   +0      <- the control: #min did not move
34 file(s): control 583 passing, treatment 588 passing, +5
1 file(s) moved, 0 regressed

Bound

A beginless range has no lower endpoint to clamp n against, so n alone
decides the allocation: (..1).max(2**40) is a request for 8 TiB from one line
of untrusted Ruby. MRI bounds it inside rb_ary_new2, which refuses a length
above ARY_MAX_SIZE with ArgumentError; this refuses with the same class. The
test asserts the refusal and that an ordinary count of 1000 still works — a
cap that refused everything would pass the first half alone.

Coverage: integerRangeMaxN, rangeBigEnds, toBigInt at 100.0%. The last
uncovered line was rangeBigEnds' non-Integer-begin refusal, reachable as
(1.0..3).max(2); the coverage gate found it, not me.

🤖 Generated with Claude Code

Five of the 27 examples that went red when rbgo moved to Ruby 4.0 (#799) are
one cause: `core/range/max_spec.rb` has `ruby_version_is "4.0"` blocks that
rbgo refused outright. The file goes 30 → 35, fail=0 error=0.

## The reference

range.c's `range_max` at v4.0.9 changed in two places that both read as "a nil
begin is not a disqualification":

    int c = NIL_P(b) ? -1 : OPTIMIZED_CMP(b, e);   // beginless sorts BELOW the end
    if (!NIL_P(b) && !RB_INTEGER_TYPE_P(b)) {      // the Integer-begin demand is
        rb_raise(... "cannot exclude end value with non Integer begin value");
    }                                              // now guarded by !NIL_P(b)

and the n-argument branch goes through `reverse_each`, which walks
`(NIL_P(beg) || RB_INTEGER_TYPE_P(beg)) && RB_INTEGER_TYPE_P(end)` downward
from the end with nothing but n to stop it.

## What moved here

* `(...1).max` is 0, not a TypeError.
* `(..1).max(n)` and `(...1).max(n)` count down from the end, unbounded below.
* Bignum endpoints answer arithmetically: `(0...2**64).max(2)` no longer tries
  to materialise 2**64 elements. The predecessor of an exclusive end is taken
  in big.Int, so the int64 floor stops being a special case —
  `(...-2**63).max` is -9223372036854775809, a Bignum, as in MRI.
* A beginless NON-Integer range raises TypeError "can't iterate from NilClass",
  not the old blanket RangeError.
* `(..1).max(0)` is `[]`.

## The guard order is observable

`range_max` asks for a block BEFORE it looks at n. `(..1).max(2) { |a,b| a<=>b }`
raises in MRI; answering the n first returns `[1, 0]` and looks entirely
reasonable. The check moved to the top of the method for that reason, and the
test says so.

## Two tests were MOVED, not relaxed

`max_n_beginless` and `max_excl_beginless_int` in range_conformance_test.go
pinned the pre-4.0 refusals — ruby/spec marks both `ruby_version_is ""..."4.0"`.
Each row now names the refusal that replaced it, so the file still asserts a
refusal for each shape rather than losing one.

## Measured, not read

Every expectation came from MRI 4.0.7 on this host through rubydiff, before and
after. Three probe programs covering 36 shapes: the first two now AGREE
byte-for-byte; the third has one remaining divergence, pre-existing and
unrelated to beginless ranges — `(1..10).max(2) { |a,b| -(a<=>b) }` is `[1, 2]`
in MRI and `[10, 9]` here, because rbgo answers `max(n)` arithmetically and
ignores the block's ordering. Filed separately rather than smuggled in.

## Bound

A beginless range has no lower endpoint to clamp n against, so n alone decides
the allocation: `(..1).max(2**40)` is a request for 8 TiB. MRI bounds it inside
`rb_ary_new2`, which refuses a length above ARY_MAX_SIZE with ArgumentError;
this refuses with the same class, and the test asserts both the refusal and
that an ordinary count of 1000 still works.

Coverage: integerRangeMaxN, rangeBigEnds and toBigInt at 100.0%. The one
uncovered line before the last test was rangeBigEnds' non-Integer-begin
refusal, reachable as `(1.0..3).max(2)`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant